/*
 * Copyright (c) 2014-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

package com.facebook.stetho.json;

import android.util.Log;
import com.facebook.stetho.common.ExceptionUtil;
import com.facebook.stetho.json.annotation.JsonProperty;
import com.facebook.stetho.json.annotation.JsonValue;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import javax.annotation.concurrent.GuardedBy;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
 * This class is a lightweight version of Jackson's ObjectMapper. It is designed to have a minimal
 * subset of the functionality required for stetho.
 *
 * <p>It would be awesome if there were a lightweight library that supported converting between
 * arbitrary {@link Object} and {@link JSONObject} representations.
 *
 * <p>Admittedly the other approach would be to use an Annotation Processor to create static
 * conversion functions that discover something like a {@link JsonProperty} and create a function at
 * compile time however since this is just being used for a simple debug utility and Kit-Kat caches
 * the results of reflection this class is sufficient for stethos needs.
 */
public class ObjectMapper {

    @GuardedBy("mJsonValueMethodCache")
    private final Map<Class<?>, Method> mJsonValueMethodCache = new IdentityHashMap<>();

    @Nullable
    private static Method getJsonValueMethodImpl(Class<?> clazz) {
        Method[] methods = clazz.getMethods();
        for (int i = 0; i < methods.length; ++i) {
            Annotation jsonValue = methods[i].getAnnotation(JsonValue.class);
            if (jsonValue != null) {
                return methods[i];
            }
        }
        return null;
    }

    private static boolean canDirectlySerializeClass(Class clazz) {
        return isWrapperOrPrimitiveType(clazz) || clazz.equals(String.class);
    }

    private static boolean isWrapperOrPrimitiveType(Class<?> clazz) {
        return clazz.isPrimitive()
                || clazz.equals(Boolean.class)
                || clazz.equals(Integer.class)
                || clazz.equals(Character.class)
                || clazz.equals(Byte.class)
                || clazz.equals(Short.class)
                || clazz.equals(Double.class)
                || clazz.equals(Long.class)
                || clazz.equals(Float.class);
    }

    /**
     * Support mapping between arbitrary classes and {@link JSONObject}. <note> It is possible for a
     * {@link Throwable} to be propagated out of this class if there is an {@link
     * InvocationTargetException}. </note>
     *
     * @param fromValue
     * @param toValueType
     * @param <T>
     * @return
     * @throws IllegalArgumentException when there is an error converting. One of either {@code
     *                                  fromValue.getClass()} or {@code toValueType} must be {@link JSONObject}.
     */
    public <T> T convertValue(Object fromValue, Class<T> toValueType)
            throws IllegalArgumentException {
        if (fromValue == null) {
            return null;
        }

        if (toValueType != Object.class && toValueType.isAssignableFrom(fromValue.getClass())) {
            return (T) fromValue;
        }

        try {
            if (fromValue instanceof JSONObject) {
                return convertFromJSONObject((JSONObject) fromValue, toValueType);
            } else if (toValueType == JSONObject.class) {
                return (T) convertToJSONObject(fromValue);
            } else {
                throw new IllegalArgumentException(
                        "Expecting either fromValue or toValueType to be a JSONObject");
            }
        } catch (NoSuchMethodException e) {
            throw new IllegalArgumentException(e);
        } catch (IllegalAccessException e) {
            throw new IllegalArgumentException(e);
        } catch (InstantiationException e) {
            throw new IllegalArgumentException(e);
        } catch (JSONException e) {
            throw new IllegalArgumentException(e);
        } catch (InvocationTargetException e) {
            throw ExceptionUtil.propagate(e.getCause());
        }
    }

    private <T> T convertFromJSONObject(JSONObject jsonObject, Class<T> type)
            throws NoSuchMethodException, IllegalAccessException, InvocationTargetException,
            InstantiationException, JSONException {
        Constructor<T> constructor = type.getDeclaredConstructor((Class[]) null);
        constructor.setAccessible(true);
        T instance = constructor.newInstance();
        Field[] fields = type.getFields();
        for (int i = 0; i < fields.length; ++i) {
            Field field = fields[i];
            if (Modifier.isStatic(field.getModifiers())) {
                continue;
            }
            Object value = jsonObject.opt(field.getName());
            Object setValue = getValueForField(field, value);
            try {
                // INSPECTOR ADD:
                // http://jira.hapjs.org/browse/ISSUE-1223
                if (setValue == null) {
                    JsonProperty annotation = field.getAnnotation(JsonProperty.class);
                    if (annotation != null) {
                        boolean required = annotation.required();
                        if (!required) {
                            Log.v(
                                    "ObjectMapper", String.format("skipping for optional value %s",
                                            field.getName()));
                            continue;
                        }
                    }
                }
                // End
                field.set(instance, setValue);
            } catch (IllegalArgumentException e) {
                throw new IllegalArgumentException(
                        "Class: "
                                + type.getSimpleName()
                                + " "
                                + "Field: "
                                + field.getName()
                                + " type "
                                + (setValue != null ? setValue.getClass().getName() : "null"),
                        e);
            }
        }
        return instance;
    }

    private Object getValueForField(Field field, Object value) throws JSONException {
        try {
            if (value != null) {
                if (value == JSONObject.NULL) {
                    return null;
                }
                if (value.getClass() == field.getType()) {
                    return value;
                }
                if (value instanceof JSONObject) {
                    return convertValue(value, field.getType());
                } else {
                    if (field.getType().isEnum()) {
                        return getEnumValue((String) value, field.getType().asSubclass(Enum.class));
                    } else if (value instanceof JSONArray) {
                        return convertArrayToList(field, (JSONArray) value);
                    } else if (value instanceof Number) {
                        // Need to convert value to Number This happens because json treats 1 as an Integer even
                        // if the field is supposed to be a Long
                        Number numberValue = (Number) value;
                        Class<?> clazz = field.getType();
                        if (clazz == Integer.class || clazz == int.class) {
                            return numberValue.intValue();
                        } else if (clazz == Long.class || clazz == long.class) {
                            return numberValue.longValue();
                        } else if (clazz == Double.class || clazz == double.class) {
                            return numberValue.doubleValue();
                        } else if (clazz == Float.class || clazz == float.class) {
                            return numberValue.floatValue();
                        } else if (clazz == Byte.class || clazz == byte.class) {
                            return numberValue.byteValue();
                        } else if (clazz == Short.class || clazz == short.class) {
                            return numberValue.shortValue();
                        } else {
                            throw new IllegalArgumentException(
                                    "Not setup to handle class " + clazz.getName());
                        }
                    }
                }
            }
        } catch (IllegalAccessException e) {
            throw new IllegalArgumentException("Unable to set value for field " + field.getName(),
                    e);
        }
        return value;
    }

    private Enum getEnumValue(String value, Class<? extends Enum> clazz) {
        Method method = getJsonValueMethod(clazz);
        if (method != null) {
            return getEnumByMethod(value, clazz, method);
        } else {
            return Enum.valueOf(clazz, value);
        }
    }

    /**
     * In this case we know that there is an {@link Enum} decorated with {@link JsonValue}. This means
     * that we need to iterate through all of the values of the {@link Enum} returned by the given
     * {@link Method} to check the given value.
     *
     * @param value
     * @param clazz
     * @param method
     * @return
     */
    private Enum getEnumByMethod(String value, Class<? extends Enum> clazz, Method method) {
        Enum[] enumValues = clazz.getEnumConstants();
        // INSPECTOR ADD BEGIN:
        if (enumValues == null) {
            throw new IllegalArgumentException("No enum constant " + clazz.getName() + "." + value);
        }
        // END
        // Start at the front to ensure first always wins
        for (int i = 0; i < enumValues.length; ++i) {
            Enum enumValue = enumValues[i];
            try {
                Object o = method.invoke(enumValue);
                if (o != null) {
                    if (o.toString().equals(value)) {
                        return enumValue;
                    }
                }
            } catch (Exception ex) {
                throw new IllegalArgumentException(ex);
            }
        }
        throw new IllegalArgumentException("No enum constant " + clazz.getName() + "." + value);
    }

    private List<Object> convertArrayToList(Field field, JSONArray array)
            throws IllegalAccessException, JSONException {
        if (List.class.isAssignableFrom(field.getType())) {
            ParameterizedType parameterizedType = (ParameterizedType) field.getGenericType();
            Type[] types = parameterizedType.getActualTypeArguments();
            if (types.length != 1) {
                throw new IllegalArgumentException(
                        "Only able to handle a single type in a list " + field.getName());
            }
            Class arrayClass = (Class) types[0];
            List<Object> objectList = new ArrayList<Object>();
            for (int i = 0; i < array.length(); ++i) {
                if (arrayClass.isEnum()) {
                    objectList.add(getEnumValue(array.getString(i), arrayClass));
                } else if (canDirectlySerializeClass(arrayClass)) {
                    objectList.add(array.get(i));
                } else {
                    JSONObject jsonObject = array.getJSONObject(i);
                    if (jsonObject == null) {
                        objectList.add(null);
                    } else {
                        objectList.add(convertValue(jsonObject, arrayClass));
                    }
                }
            }
            return objectList;
        } else {
            throw new IllegalArgumentException(
                    "only know how to deserialize List<?> on field " + field.getName());
        }
    }

    private JSONObject convertToJSONObject(Object fromValue)
            throws JSONException, InvocationTargetException, IllegalAccessException {
        JSONObject jsonObject = new JSONObject();
        Field[] fields = fromValue.getClass().getFields();
        for (int i = 0; i < fields.length; ++i) {
            Field field = fields[i];
            if (Modifier.isStatic(field.getModifiers())) {
                continue;
            }
            JsonProperty property = field.getAnnotation(JsonProperty.class);
            if (property != null) {
                // AutoBox here ...
                Object value = field.get(fromValue);
                Class clazz = field.getType();
                if (value != null) {
                    clazz = value.getClass();
                }
                String name = field.getName();
                if (property.required() && value == null) {
                    value = JSONObject.NULL;
                } else if (value == JSONObject.NULL) {
                    // Leave it as null in this case.
                } else {
                    value = getJsonValue(value, clazz, field);
                }
                jsonObject.put(name, value);
            }
        }
        return jsonObject;
    }

    private Object getJsonValue(Object value, Class<?> clazz, Field field)
            throws InvocationTargetException, IllegalAccessException {
        if (value == null) {
            // Now technically we /could/ return JsonNode.NULL here but Chrome's webkit inspector croaks
            // if you pass a null "id"
            return null;
        }
        if (List.class.isAssignableFrom(clazz)) {
            return convertListToJsonArray(value);
        }
        // Finally check to see if there is a JsonValue present
        Method m = getJsonValueMethod(clazz);
        if (m != null) {
            return m.invoke(value);
        }
        if (!canDirectlySerializeClass(clazz)) {
            return convertValue(value, JSONObject.class);
        }
        // JSON has no support for NaN, Infinity or -Infinity, so we serialize
        // then as strings. Google Chrome's inspector will accept them just fine.
        if (clazz.equals(Double.class) || clazz.equals(Float.class)) {
            double doubleValue = ((Number) value).doubleValue();
            if (Double.isNaN(doubleValue)) {
                return "NaN";
            } else if (doubleValue == Double.POSITIVE_INFINITY) {
                return "Infinity";
            } else if (doubleValue == Double.NEGATIVE_INFINITY) {
                return "-Infinity";
            }
        }
        // hmm we should be able to directly serialize here...
        return value;
    }

    private JSONArray convertListToJsonArray(Object value)
            throws InvocationTargetException, IllegalAccessException {
        JSONArray array = new JSONArray();
        List<Object> list = (List<Object>) value;
        for (Object obj : list) {
            // Send null, if this is an array of arrays we are screwed
            array.put(obj != null ? getJsonValue(obj, obj.getClass(), null /* field */) : null);
        }
        return array;
    }

    /**
     * @param clazz
     * @return the first method annotated with {@link JsonValue} or null if one does not exist.
     */
    @Nullable
    private Method getJsonValueMethod(Class<?> clazz) {
        synchronized (mJsonValueMethodCache) {
            Method method = mJsonValueMethodCache.get(clazz);
            if (method == null && !mJsonValueMethodCache.containsKey(clazz)) {
                method = getJsonValueMethodImpl(clazz);
                mJsonValueMethodCache.put(clazz, method);
            }
            return method;
        }
    }
}
